Skip to content

feat(checkout): attach a persistent Stripe Customer at payment (stop 'Guest' sales)#264

Merged
kilbot merged 2 commits into
mainfrom
feat/stripe-customer-capture
Jul 6, 2026
Merged

feat(checkout): attach a persistent Stripe Customer at payment (stop 'Guest' sales)#264
kilbot merged 2 commits into
mainfrom
feat/stripe-customer-capture

Conversation

@kilbot

@kilbot kilbot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What & why

Our first live sale showed in Stripe as a Guest — no cus_... attached to the PaymentIntent. This wires the checkout so every purchase attaches a persistent Stripe Customer, linked to the Medusa customer.

"Guest" in Stripe ≠ guest checkout. The buyer did register a Medusa account (we have email, name, billing address). The gap is purely that no Stripe Customer was linked.

Root cause

Medusa v2 already creates a Stripe Customer automatically — we just never triggered it:

  1. The store payment-sessions route derives the customer from the JWT: customer_id: req.auth_context?.actor_id.
  2. The create-payment-session workflow only creates/links a Stripe account holder when("customer-id-exists").
  3. The storefront's medusaFetch sends only x-publishable-api-key — never the customer's bearer token.
  4. → Medusa sees an unauthenticated request → actor_id empty → no account holder → intentRequest.customer undefined → Stripe invents a "Guest".

The change

Forward the signed-in customer's Medusa JWT on createPaymentCollection and createPaymentSession (server-side, in /api/store/cart/payment-sessions).

Why this is safe: the route already calls getCustomer(), which validates the token against /store/customers/me (Medusa rejects a JWT with an empty actor_id). So a non-null customer guarantees the forwarded token resolves to a real customer. A missing token degrades to the previous guest behaviour rather than breaking checkout. The token param is optional, so anonymous/mock callers are unaffected.

Validation

  • vitest run1155/1155 unit tests pass (added header-forwarding + null-token + updated route call-arg tests)
  • eslint — clean
  • tsc --noEmit — clean
  • next build — clean

Not verified live: confirming a real Stripe Customer (not Guest) appears after an actual card payment requires a live purchase, which I didn't run. Recommend a single small test/real purchase after deploy to confirm end-to-end.

Scope & follow-ups

This PR is attach-customer only (Level 1 — bookkeeping/reconciliation, stops Guest sprawl). Deliberately not in scope:

  • Save card for off-session renewals (Level 2) — separate PR (setup_future_usage: 'off_session' for yearly only + a consent checkbox). Needs owner decisions on consent wording/default, so it'll come as a draft.
  • Q2 — order↔customer linkage: the rest of the checkout (cart create/complete) is still publishable-key-only, so orders may still be linked to a guest customer record. This PR fixes the Stripe side; confirming/authenticating the whole checkout wants live DB verification (blocked here by the 1Password ssh-signing constraint).
  • Renewal charge flow (Level 3) — the actual payoff of saving a card; larger, own PR, needs SCA fallback (EU/PL) + Card Account Updater.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved cart payment session creation so authenticated customer tokens are forwarded through payment requests, preserving the correct customer context for payments.
    • If no token is available, payment creation still succeeds while safely omitting the authorization header.
    • Updated test coverage to verify consistent authorization-token handling across payment collection and payment session creation, including existing cart payment flows.

Forward the signed-in customer's Medusa JWT on the payment-collection and
payment-session calls so Medusa resolves `auth_context.actor_id` to the
customer and creates/links a Stripe account holder (cus_...) on the
PaymentIntent — instead of the throwaway "Guest" that Stripe invents for
publishable-key-only requests.

The store payment-sessions workflow only creates an account holder
`when("customer-id-exists")`, and `customer_id` is derived solely from the
request's auth context; today the storefront's medusaFetch sends only the
publishable key, so actor_id is empty and every sale shows as Guest.

getCustomer() in the route already validates the token against
/store/customers/me (which rejects an empty actor_id), so the forwarded
token is guaranteed to resolve to a real customer. A missing token degrades
to the previous guest behaviour rather than breaking checkout.

Scope: attach-customer only (bookkeeping/reconciliation). Saving the card
for off-session renewals is a separate follow-up.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kilbot has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e2faf75-e6a5-4322-91c0-a0e8cd9f61db

📥 Commits

Reviewing files that changed from the base of the PR and between a76edf1 and 639e21b.

📒 Files selected for processing (1)
  • src/services/core/external/medusa-client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/services/core/external/medusa-client.ts

📝 Walkthrough

Walkthrough

The Medusa payment-session flow now retrieves a customer auth token in the route, forwards it into payment collection/session creation, and sends it as a Bearer header from the Medusa client when present. Tests cover token propagation and missing-token behavior.

Changes

Auth token forwarding for Medusa payment calls

Layer / File(s) Summary
Medusa client: optional authToken support
src/services/core/external/medusa-client.ts, src/services/core/external/medusa-client.test.ts
createPaymentCollection and createPaymentSession accept an optional authToken parameter and add an Authorization: Bearer header when provided; tests cover header presence and absence.
Route: retrieve and forward auth token
src/app/api/store/cart/payment-sessions/route.ts
The POST handler imports getAuthToken, retrieves the token after customer validation, and passes it into both Medusa payment calls.
Route tests: verify token propagation and missing-token handling
src/app/api/store/cart/payment-sessions/route.test.ts
Mocks getAuthToken, adds an AUTH_TOKEN constant, updates existing scenario assertions to expect the token, and adds tests for JWT forwarding and missing-token handling.

Estimated code review effort: 2 (Simple) | ~15 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the checkout change to forward customer auth so Stripe customers persist instead of appearing as guest sales.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stripe-customer-capture

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/services/core/external/medusa-client.ts (1)

353-379: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate header-forwarding logic between the two functions.

Both functions repeat the identical ...(authToken ? {headers: {Authorization: ...}} : {}) pattern. A small shared helper (e.g. buildAuthHeaders(authToken)) would remove the duplication, though the current form is simple enough to be low priority.

Also applies to: 392-414

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/core/external/medusa-client.ts` around lines 353 - 379, Both
createPaymentCollection and the related createPaymentSession path repeat the
same authToken-to-Authorization header spread logic, so extract it into a shared
helper such as buildAuthHeaders(authToken) and use that helper from
medusa-client.ts to keep the header construction in one place and remove
duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/services/core/external/medusa-client.ts`:
- Around line 353-379: Both createPaymentCollection and the related
createPaymentSession path repeat the same authToken-to-Authorization header
spread logic, so extract it into a shared helper such as
buildAuthHeaders(authToken) and use that helper from medusa-client.ts to keep
the header construction in one place and remove duplication.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 665050f5-b837-4905-b4f3-4f5f30dd9238

📥 Commits

Reviewing files that changed from the base of the PR and between 2ef1925 and a76edf1.

📒 Files selected for processing (4)
  • src/app/api/store/cart/payment-sessions/route.test.ts
  • src/app/api/store/cart/payment-sessions/route.ts
  • src/services/core/external/medusa-client.test.ts
  • src/services/core/external/medusa-client.ts

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

🚀 Preview: https://wcpos-p4its6x7a-wcpos.vercel.app

De-duplicate the authToken -> Authorization header spread shared by
createPaymentCollection and createPaymentSession into one buildAuthHeaders()
helper. Behaviour-preserving (returns the same Bearer header, or {} when no
token); addresses a review nitpick.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kilbot has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🚀 Preview: https://wcpos-n905smtmk-wcpos.vercel.app

@kilbot kilbot merged commit fbbd1b3 into main Jul 6, 2026
9 checks passed
@kilbot kilbot deleted the feat/stripe-customer-capture branch July 6, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant